ShareViewController.swift (17699B)
1 // 2 // ShareViewController.swift 3 // share extension 4 // 5 // Created by Swift on 11/4/24. 6 // 7 8 import SwiftUI 9 import Social 10 import UniformTypeIdentifiers 11 12 let this_app: UIApplication = UIApplication() 13 14 class ShareViewController: SLComposeServiceViewController { 15 private var contentView: UIHostingController<ShareExtensionView>? 16 17 override func viewDidLoad() { 18 super.viewDidLoad() 19 self.view.tintColor = UIColor(DamusColors.purple) 20 21 DispatchQueue.main.async { 22 let contentView = UIHostingController(rootView: ShareExtensionView(extensionContext: self.extensionContext!, 23 dismissParent: { [weak self] in 24 self?.dismissSelf() 25 } 26 )) 27 self.addChild(contentView) 28 self.contentView = contentView 29 self.view.addSubview(contentView.view) 30 31 // set up constraints 32 contentView.view.translatesAutoresizingMaskIntoConstraints = false 33 contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true 34 contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true 35 contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true 36 contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true 37 } 38 } 39 40 func dismissSelf() { 41 super.didSelectCancel() 42 } 43 } 44 45 struct ShareExtensionView: View { 46 @State private var share_state: ShareState = .loading 47 let extensionContext: NSExtensionContext 48 @State private var state: DamusState? = nil 49 @State private var preUploadedMedia: [PreUploadedMedia] = [] 50 var dismissParent: (() -> Void)? 51 52 @Environment(\.scenePhase) var scenePhase 53 54 var body: some View { 55 VStack(spacing: 15) { 56 switch self.share_state { 57 case .loading: 58 ProgressView() 59 case .no_content: 60 Group { 61 Text("No content available to share", comment: "Title indicating that there was no available content to share") 62 .font(.largeTitle) 63 .multilineTextAlignment(.center) 64 .padding() 65 Text("There is no content available to share at this time. Please close this view and try again.", comment: "Label explaining that no content is available to share and instructing the user to close the view and try again.") 66 .multilineTextAlignment(.center) 67 .padding(.horizontal) 68 69 Button(action: { 70 self.done() 71 }, label: { 72 Text("Close", comment: "Button label giving the user the option to close the view when no content is available to share") 73 }) 74 .foregroundStyle(.secondary) 75 } 76 case .not_logged_in: 77 Group { 78 Text("Not Logged In", comment: "Title indicating that sharing cannot proceed because the user is not logged in.") 79 .font(.largeTitle) 80 .multilineTextAlignment(.center) 81 .padding() 82 83 Text("You cannot share content because you are not logged in. Please close this view, log in to your account, and try again.", comment: "Label explaining that sharing cannot proceed because the user is not logged in.") 84 .multilineTextAlignment(.center) 85 .padding(.horizontal) 86 87 Button(action: { 88 self.done() 89 }, label: { 90 Text("Close", comment: "Button label giving the user the option to close the sheet due to not being logged in.") 91 }) 92 .foregroundStyle(.secondary) 93 } 94 case .loaded(let content): 95 PostView( 96 action: .sharing(content), 97 damus_state: state! // state will have a value at this point 98 ) 99 case .cancelled: 100 Group { 101 Text("Cancelled", comment: "Title indicating that the user has cancelled.") 102 .font(.largeTitle) 103 .padding() 104 Button(action: { 105 self.done() 106 }, label: { 107 Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to share.") 108 }) 109 .foregroundStyle(.secondary) 110 } 111 case .failed(let error): 112 Group { 113 Text("Error", comment: "Title indicating that an error has occurred.") 114 .font(.largeTitle) 115 .multilineTextAlignment(.center) 116 .padding() 117 Text("An unexpected error occurred. Please contact Damus support via [Nostr](damus:npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955) or [email](support@damus.io) with the error message below.", comment: "Label explaining there was an error, and suggesting next steps") 118 .multilineTextAlignment(.center) 119 Text("Error: \(error)") 120 Button(action: { 121 done() 122 }, label: { 123 Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying share.") 124 }) 125 .foregroundStyle(.secondary) 126 } 127 case .posted(event: let event): 128 Group { 129 Image(systemName: "checkmark.circle.fill") 130 .resizable() 131 .frame(width: 60, height: 60) 132 Text("Shared", comment: "Title indicating that the user has shared content successfully") 133 .font(.largeTitle) 134 .multilineTextAlignment(.center) 135 .padding(.bottom) 136 137 Link(destination: URL(string: "damus:\(event.id.bech32)")!, label: { 138 Text("Go to the app", comment: "Button label giving the user the option to go to the app after sharing content") 139 }) 140 .buttonStyle(GradientButtonStyle()) 141 142 Button(action: { 143 self.done() 144 }, label: { 145 Text("Close", comment: "Button label giving the user the option to close the sheet from which they shared content") 146 }) 147 .foregroundStyle(.secondary) 148 } 149 case .posting: 150 Group { 151 ProgressView() 152 .frame(width: 20, height: 20) 153 Text("Sharing", comment: "Title indicating that the content is being published to the network") 154 .font(.largeTitle) 155 .multilineTextAlignment(.center) 156 .padding(.bottom) 157 Text("Your content is being broadcasted to the network. Please wait.", comment: "Label explaining that their content sharing action is in progress") 158 .multilineTextAlignment(.center) 159 .padding() 160 } 161 } 162 } 163 .onAppear(perform: { 164 if setDamusState() { 165 self.loadSharedContent() 166 } 167 }) 168 .onDisappear { 169 Task { @MainActor in 170 self.state?.ndb.close() 171 } 172 } 173 .onReceive(handle_notify(.post)) { post_notification in 174 switch post_notification { 175 case .post(let post): 176 self.post(post) 177 case .cancel: 178 self.share_state = .cancelled 179 dismissParent?() 180 } 181 } 182 .onChange(of: scenePhase) { (phase: ScenePhase) in 183 guard let state else { return } 184 switch phase { 185 case .background: 186 print("txn: 📙 SHARE BACKGROUNDED") 187 Task { @MainActor in 188 state.ndb.close() 189 } 190 break 191 case .inactive: 192 print("txn: 📙 SHARE INACTIVE") 193 break 194 case .active: 195 print("txn: 📙 SHARE ACTIVE") 196 state.nostrNetwork.pool.ping() 197 @unknown default: 198 break 199 } 200 } 201 .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in 202 guard let state else { return } 203 print("SHARE ACTIVE NOTIFY") 204 if state.ndb.reopen() { 205 print("SHARE NOSTRDB REOPENED") 206 } else { 207 print(" SHARE NOSTRDB FAILED TO REOPEN closed: \(state.ndb.is_closed)") 208 } 209 } 210 .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { obj in 211 guard let state else { return } 212 print("txn: 📙 SHARE BACKGROUNDED") 213 Task { @MainActor in 214 state.ndb.close() 215 } 216 } 217 } 218 219 func post(_ post: NostrPost) { 220 self.share_state = .posting 221 guard let state else { 222 self.share_state = .failed(error: "Damus state not initialized") 223 return 224 } 225 guard let full_keypair = state.keypair.to_full() else { 226 self.share_state = .not_logged_in 227 return 228 } 229 guard let posted_event = post.to_event(keypair: full_keypair) else { 230 self.share_state = .failed(error: "Cannot convert post data into a nostr event") 231 return 232 } 233 state.nostrNetwork.postbox.send(posted_event, on_flush: .once({ flushed_event in 234 if flushed_event.event.id == posted_event.id { 235 DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { // Offset labor perception bias 236 self.share_state = .posted(event: flushed_event.event) 237 }) 238 } 239 else { 240 self.share_state = .failed(error: "Flushed event is not the event we just tried to post.") 241 } 242 })) 243 } 244 245 @discardableResult 246 private func setDamusState() -> Bool { 247 guard let keypair = get_saved_keypair(), 248 keypair.privkey != nil else { 249 self.share_state = .not_logged_in 250 return false 251 } 252 state = DamusState(keypair: keypair) 253 state?.nostrNetwork.connect() 254 return true 255 } 256 257 func loadSharedContent() { 258 guard let extensionItem = extensionContext.inputItems.first as? NSExtensionItem else { 259 share_state = .failed(error: "Unable to get item provider") 260 return 261 } 262 263 var title = "" 264 265 // Check for the attributed text from the extension item 266 if let attributedContentData = extensionItem.userInfo?[NSExtensionItemAttributedContentTextKey] as? Data { 267 if let attributedText = try? NSAttributedString(data: attributedContentData, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) { 268 let plainText = attributedText.string 269 print("Extracted Text: \(plainText)") 270 title = plainText 271 } else { 272 print("Failed to decode RTF content.") 273 } 274 } else { 275 print("Content is not in RTF format or data is unavailable.") 276 } 277 278 // Iterate through all attachments to handle multiple images 279 for itemProvider in extensionItem.attachments ?? [] { 280 if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { 281 itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in 282 if let url = item as? URL { 283 284 attemptAcquireResourceAndChooseMedia( 285 url: url, 286 fallback: processImage, 287 unprocessedEnum: {.unprocessed_image($0)}, 288 processedEnum: {.processed_image($0)}) 289 290 } else if let image = item as? UIImage { 291 // process it directly if shared item is uiimage (example: image shared from Facebook, Signal apps) 292 chooseMedia(PreUploadedMedia.uiimage(image)) 293 } else { 294 self.share_state = .failed(error: "Failed to load image content") 295 } 296 } 297 } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { 298 itemProvider.loadItem(forTypeIdentifier: UTType.movie.identifier) { (item, error) in 299 if let url = item as? URL { 300 attemptAcquireResourceAndChooseMedia( 301 url: url, 302 fallback: processVideo, 303 unprocessedEnum: {.unprocessed_video($0)}, 304 processedEnum: {.processed_video($0)} 305 ) 306 307 } else { 308 self.share_state = .failed(error: "Failed to load video content") 309 } 310 } 311 } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { 312 itemProvider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (item, error) in 313 // Sharing URLs from iPhone/Safari to Damus also follows this pathway 314 // Sharing Photos or Links from macOS/Finder or macOS/Safari to Damus sets item-provider conforming to UTType.url.identifier and therefore takes this pathway 315 316 if let url = item as? URL { 317 // Sharing Photos from macOS/Finder 318 if url.absoluteString.hasPrefix("file:///") { 319 attemptAcquireResourceAndChooseMedia( 320 url: url, 321 fallback: processImage, 322 unprocessedEnum: {.unprocessed_image($0)}, 323 processedEnum: {.processed_image($0)}) 324 325 } else { 326 // Sharing URLs from iPhone/Safari to Damus 327 self.share_state = .loaded(ShareContent(title: title, content: .link(url))) 328 } 329 } else if let data = item as? Data, 330 let string = String(data: data, encoding: .utf8), 331 let url = URL(string: string) { 332 // Sharing Links from macOS/Safari, does not provide title 333 self.share_state = .loaded(ShareContent(title: "", content: .link(url))) 334 } else { 335 self.share_state = .failed(error: "Failed to load text content") 336 } 337 } 338 } else { 339 share_state = .no_content 340 } 341 } 342 343 func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) { 344 if url.startAccessingSecurityScopedResource() { 345 // Have permission from system to use url out of scope 346 print("Acquired permission to security scoped resource") 347 chooseMedia(unprocessedEnum(url)) 348 } else { 349 // Need to copy URL to non-security scoped location 350 guard let newUrl = fallback(url) else { return } 351 chooseMedia(processedEnum(newUrl)) 352 } 353 } 354 355 func chooseMedia(_ media: PreUploadedMedia) { 356 self.preUploadedMedia.append(media) 357 if extensionItem.attachments?.count == preUploadedMedia.count { 358 self.share_state = .loaded(ShareContent(title: "", content: .media(preUploadedMedia))) 359 } 360 } 361 } 362 363 private func done() { 364 extensionContext.completeRequest(returningItems: [], completionHandler: nil) 365 } 366 367 private enum ShareState { 368 case loading 369 case no_content 370 case not_logged_in 371 case loaded(ShareContent) 372 case failed(error: String) 373 case cancelled 374 case posting 375 case posted(event: NostrEvent) 376 } 377 } 378